package com.avcompris.util.reflect;

import static com.avcompris.util.ExceptionUtils.nonNullArgument;
import static com.avcompris.util.reflect.AbstractClassTestUtils.loadFileClasses;
import static java.lang.reflect.Modifier.isAbstract;
import static java.lang.reflect.Modifier.isFinal;
import static java.lang.reflect.Modifier.isStatic;
import static java.lang.reflect.Modifier.isTransient;
import static junit.framework.Assert.fail;
import static org.apache.commons.lang3.ArrayUtils.EMPTY_OBJECT_ARRAY;
import static org.apache.commons.lang3.ArrayUtils.contains;
import static org.apache.log4j.Logger.getLogger;
import static org.mockito.Mockito.mock;

import java.io.File;
import java.io.Reader;
import java.io.StringReader;
import java.io.StringWriter;
import java.io.Writer;
import java.lang.annotation.Annotation;
import java.lang.reflect.AccessibleObject;
import java.lang.reflect.Array;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Member;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.lang.reflect.WildcardType;
import java.sql.Driver;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;

import org.apache.commons.lang3.ArrayUtils;
import org.apache.log4j.Logger;
import org.junit.Test;

import com.avcompris.common.annotation.Nullable;
import com.avcompris.lang.NotImplementedException;
import com.avcompris.lang.NullArgumentException;
import com.avcompris.util.junit.AvcParameterized;
import com.google.common.base.Preconditions;

/**
 * tests on Java classes to check if the method parameters that are not flagged
 * as {@link Nullable} are checked for <tt>null</tt>-values.
 * 
 * @author David Andriana Copyright Avantage Compris SARL 2009 ©
 */
public abstract class AbstractNonNullTest {

	/**
	 * the Java method or constructor to test, seen as an
	 * {@link AccessibleObject}.
	 */
	private final AccessibleObject memberAsAccessibleObject;

	/**
	 * the Java method or constructor to test, seen as a {@link Member}.
	 */
	private final Member member;

	/**
	 * the parameter types for the method or constructor to test.
	 */
	private final Class<?>[] paramTypes;

	/**
	 * the instance to test the methods on, or <tt>null</tt>.
	 */
	private final Object instance;

	/**
	 * the canonical non-null parameters for the invocations of the Java method
	 * or constructor to set.
	 */
	private final Object[] canonicalParams;

	/**
	 * the instance+method holder for tests.
	 */
	private final MemberHolder holder;

	/**
	 * holder for what is needed to test method or constructor invocations.
	 */
	protected static final class MemberHolder {

		/**
		 * constructor.
		 * 
		 * @param instance the
		 *            instance to test the methods on, or <tt>null</tt>
		 * @param member
		 *            the Java method or constructor to test
		 * @param targets
		 *            the other classes or instances to test, so we can inject
		 *            them in tests
		 */
		public MemberHolder(
				@Nullable final Object instance,
				final Member member,
				final Object... targets) {

			this.instance = instance;
			this.member = nonNullArgument(member);
			this.targets = nonNullArgument(targets);
		}

		/**
		 * the instance to test the methods on, or <tt>null</tt>.
		 */
		public final Object instance;

		/**
		 * the Java method or constructor to test.
		 */
		public final Member member;

		/**
		 * the other classes or instances to test, so we can inject them in
		 * tests.
		 */
		public final Object[] targets;
	}

	/**
	 * constructor for a parameterized test class.
	 * 
	 * @param holder
	 *            the instance+member to test
	 */
	protected AbstractNonNullTest(final MemberHolder holder) throws Exception {

		this.holder = nonNullArgument(holder);

		this.instance = holder.instance;
		this.member = holder.member;

		this.memberAsAccessibleObject = (AccessibleObject) member;

		if (member instanceof Method) {

			final Method method = (Method) member;

			paramTypes = method.getParameterTypes();

		} else if (member instanceof Constructor<?>) {

			final Constructor<?> constructor = (Constructor<?>) member;

			paramTypes = constructor.getParameterTypes();

		} else {

			throw new IllegalArgumentException("Unknown Member type: "
					+ member.getClass().getName());
		}

		boolean add = true;

		try {

			memberAsAccessibleObject.setAccessible(true);

		} catch (final SecurityException e) {

			add = false;
		}

		if (add) {

			canonicalParams = getCanonicalParams(member, holder.targets);

		} else {

			canonicalParams = new Object[]{
				holder.instance
			};
		}
	}

	/**
	 * construct canonical params for a method or constructor invocation.
	 * 
	 * @param member
	 *            the Class member to test: Method or Constructor
	 * @parem targets the Java classes or instances declared as targets
	 * @return the canonical params found or instantiated
	 * @throws NoSuchMethodException
	 * @throws SecurityException
	 */
	private static Object[] getCanonicalParams(final Member member,
			final Object[] targets) throws SecurityException, NoSuchMethodException {

		nonNullArgument(member, "member");
		nonNullArgument(targets, "targets");

		final Class<?>[] types;
		final Annotation[][] annotations;
		final Type[] extendsParameterizedTypes;

		if (member instanceof Method) {

			final Method method = (Method) member;

			types = method.getParameterTypes();
			annotations = method.getParameterAnnotations();
			extendsParameterizedTypes = new Type[types.length];

		} else if (member instanceof Constructor<?>) {

			final Constructor<?> constructor = (Constructor<?>) member;

			types = constructor.getParameterTypes();
			annotations = constructor.getParameterAnnotations();
			extendsParameterizedTypes = new Type[types.length];

			if (constructor.isSynthetic()) { // synthetic = compiler-generated

				return new Object[types.length]; // all @Nullable!
			}

			int i = 0;

			for (final Type genericParameterType : constructor
					.getGenericParameterTypes()) {

				if (genericParameterType instanceof ParameterizedType) {

					final ParameterizedType parameterizedType = (ParameterizedType) genericParameterType;

					for (final Type actualTypeArgument : parameterizedType
							.getActualTypeArguments()) {

						if (actualTypeArgument != null
								&& actualTypeArgument instanceof WildcardType) {

							final WildcardType wildcardType = (WildcardType) actualTypeArgument;

							for (final Type upperBound : wildcardType
									.getUpperBounds()) {

								extendsParameterizedTypes[i] = upperBound;
							}
						}
					}
				}

				++i;
			}

		} else {

			throw new IllegalArgumentException("Unknown Member type: "
					+ member.getClass().getName());
		}

		final Object[] params = new Object[types.length];

		final Map<Class<?>, Object> instances = new HashMap<Class<?>, Object>();

		for (final Object target : targets) {

			if (target instanceof Class<?>) {

				continue;

			} else if (target instanceof UseInstance) {

				final UseInstance useInstance = (UseInstance) target;

				instances.put(useInstance.type, useInstance.instance);

			} else {

				instances.put(target.getClass(), target);
			}
		}

		// TODO There's a trick if params.length = 1: Do not store an object,
		// just flag the param,
		// since it will be nullified for tests (or maybe only for returns...)

		for (int i = 0; i < types.length; ++i) {

			Nullable nullableAnnotation = null;

			for (int j = 0; j < annotations[i].length; ++j) {

				final Annotation annotation = annotations[i][j];

				if (Nullable.class.equals(annotation.annotationType())) {

					nullableAnnotation = (Nullable) annotation;

					break;
				}
			}

			if (nullableAnnotation != null) {

				// TODO assertParamWasNotDeclaredNotNullInSuperclasses()

				// assertParamWasNotDeclaredNotNullInSuperclasses(method, i);
			}

			final Object canonicalParam = getCanonicalParam(nullableAnnotation,
					i, types, extendsParameterizedTypes, instances, member,
					targets);

			params[i] = canonicalParam;
		}

		return params;
	}

	private static Object getCanonicalParam(
			@Nullable final Nullable nullableAnnotation, final int i,
			final Class<?>[] types, final Type[] extendsParameterizedTypes,
			final Map<Class<?>, Object> instances, final Member member,
			final Object[] targets) throws SecurityException, NoSuchMethodException {

		nonNullArgument(member, "member");
		nonNullArgument(targets, "targets");
		nonNullArgument(instances, "instances");
		nonNullArgument(types, "types");
		nonNullArgument(extendsParameterizedTypes, "extendsParameterizedTypes");

		if (nullableAnnotation != null) {

			return null;

		} else {

			// the parameter is not @Nullable

			final Class<?> paramType = types[i];

			if (instances.containsKey(paramType)) {

				return instances.get(paramType);
			}

			final Type extendsParameterizedType = extendsParameterizedTypes[i];

			if (paramType.isArray()) {

				return Array.newInstance(paramType.getComponentType(), 0);

			} else if (paramType.isPrimitive()) {

				if (int.class.equals(paramType)) {

					return Integer.valueOf(0);

				} else if (long.class.equals(paramType)) {

					return Long.valueOf(0L);

				} else if (boolean.class.equals(paramType)) {

					return Boolean.FALSE;

				} else if (char.class.equals(paramType)) {

					return Character.valueOf(' ');

				} else if (byte.class.equals(paramType)) {

					return Byte.valueOf((byte) 0);

				} else {

					throw new NotImplementedException(
							"Unknown primitive type: " + paramType.getName());
				}

			} else if (Reader.class.equals(paramType)) {

				return new StringReader("");

			} else if (Writer.class.equals(paramType)) {

				return new StringWriter();

			} else if (Driver.class.equals(paramType)) {

				return new org.h2.Driver();

			} else if (isFinal(paramType.getModifiers())) {

				//				if (instances.containsKey(paramType)) {
				//
				//					return instances.get(paramType);
				//
				//				} else 
				if (Class.class.equals(paramType)) {

					if (extendsParameterizedType == null) {

						return String.class;

					} else if (Driver.class.equals(extendsParameterizedType)) {

						return org.h2.Driver.class;

					} else {

						return extendsParameterizedType;
					}

				} else if (Method.class.equals(paramType)) {

					return String.class.getMethod("toString");

				} else {

					return newInstance("Unknown final type #" + i + ": "
							+ paramType.getName() + " for member: " + member,
							paramType, targets);
				}
			}

			return mock(paramType);
		}
	}

	/**
	 * create a new instance for tests, with default or mock values.
	 * 
	 * @param message
	 *            the error message to send if an error occurs
	 * @param c
	 *            the Java class to instantiate
	 * @param targets
	 *            the Java classes or instances declared
	 * @return the newly instantiated object to be used in tests
	 * @throws NoSuchMethodException
	 * @throws SecurityException
	 */
	private static Object newInstance(@Nullable final String message,
			final Class<?> c, final Object[] targets) throws SecurityException, NoSuchMethodException {

		nonNullArgument(message, "message");
		nonNullArgument(c, "class");
		nonNullArgument(targets, "targets");

		if (c.isEnum()) {
			throw new IllegalArgumentException(
					"Cannot instantiate Enum class: " + c.getName());
		}

		Constructor<?> defaultConstructor = null;

		try {

			defaultConstructor = c.getConstructor();

		} catch (final NoSuchMethodException e) {
			defaultConstructor = null;
		}

		final Constructor<?>[] constructors = c.getDeclaredConstructors();

		final Constructor<?>[] publicConstructors = c.getConstructors();

		final Constructor<?> constructor;

		final Object[] constructorParams;

		if (defaultConstructor != null) {

			constructor = defaultConstructor;

			constructorParams = EMPTY_OBJECT_ARRAY;

		} else if (constructors.length == 1) {

			constructor = constructors[0];

			constructorParams = getCanonicalParams(constructor, targets);

		} else if (constructors.length == 2) {

			final DetermineConstructor determine = determineConstructor(c,
					constructors, targets, message);

			constructor = determine.constructor;

			constructorParams = determine.constructorParams;

		} else if (publicConstructors.length == 2) {

			final DetermineConstructor determine = determineConstructor(c,
					publicConstructors, targets, message);

			constructor = determine.constructor;

			constructorParams = determine.constructorParams;

		} else {

			throw new NotImplementedException("Too many constructors ("
					+ constructors.length + "). " + message);
		}

		final Object instance;

		try {

			constructor.setAccessible(true);

			instance = constructor.newInstance(constructorParams);

		} catch (final Exception e) {
			throw new NotImplementedException(message, e);
		}

		return instance;
	}

	private static class DetermineConstructor {

		public DetermineConstructor(
				final Constructor<?> constructor,
				final Object[] constructorParams) {
			this.constructor = nonNullArgument(constructor, "constructor");
			this.constructorParams = nonNullArgument(constructorParams,
					"constructorParams ");
		}

		public final Constructor<?> constructor;
		public final Object[] constructorParams;
	}

	private static DetermineConstructor determineConstructor(final Class<?> c,
			final Constructor<?>[] constructors, final Object[] targets,
			final String message) throws SecurityException, NoSuchMethodException {

		nonNullArgument(c, "class");
		nonNullArgument(constructors, "constructors");
		nonNullArgument(targets, "targets");
		nonNullArgument(message, "message");

		int copyConstructorIndex = -1;

		constructorLoop: for (int i = 0; i < constructors.length; ++i) {

			final Class<?>[] paramTypes = constructors[i].getParameterTypes();

			//				if (paramTypes.length == 1 && c.equals(paramTypes[0])) {
			//
			//					copyConstructorIndex = i;
			//
			//					break;
			//				}

			for (final Class<?> paramType : paramTypes) {

				if (c.equals(paramType)) {

					copyConstructorIndex = i;

					break constructorLoop;
				}
			}
		}

		if (copyConstructorIndex != -1) {

			final Constructor<?> constructor = constructors[1 - copyConstructorIndex];

			final Object[] constructorParams = getCanonicalParams(constructor,
					targets);

			return new DetermineConstructor(constructor, constructorParams);

		} else {

			throw new NotImplementedException("Too many constructors ("
					+ constructors.length + "). " + message);
		}
	}

	/**
	 * construct the right instance+method parameters for the @
	 * {@link AvcParameterized} test class that inherits this one, from a list
	 * of classes or instance objects: Classes will be tested with default
	 * instances, whereas instance objects will be tested for themselves.
	 * 
	 * @param targets
	 *            the list of classes or instance objects
	 * @return the right instance+method parameters for the @
	 *         {@link AvcParameterized} test class that inherits this one
	 */
	protected static Collection<Object[]> parametersDoNotScanProject(
			final Object... targets) {

		nonNullArgument(targets, "targets");

		final Collection<Object[]> params = new ArrayList<Object[]>();

		// declared classes

		for (final Object target : targets) {

			final Object instance;

			final Class<?> c;

			if (target instanceof Class<?>) {

				instance = null;

				c = (Class<?>) target;

			} else if (target instanceof UseInstance) {

				final UseInstance useInstance = (UseInstance) target;

				instance = useInstance.instance;

				c = useInstance.type;

				if (!useInstance.addToTests) {

					continue;
				}

			} else {

				instance = target;

				c = target.getClass();
			}

			addToTests(params, instance, c, targets);
		}

		return params;
	}

	/**
	 * the logger to use.
	 */
	private static final Logger logger = getLogger(AbstractNonNullTest.class);

	/**
	 * add a class to params.
	 * 
	 * @param params
	 *            the current params, to add the new params to
	 * @param instance
	 *            the instance to check, or <tt>null</tt>
	 * @param c
	 *            the class to test
	 * @param targets
	 *            the declared Java class or instance to test
	 */
	private static void addToTests(final Collection<Object[]> params,
			@Nullable final Object instance, final Class<?> c,
			final Object[] targets) {

		nonNullArgument(params, "params");
		nonNullArgument(c, "class");
		nonNullArgument(targets, "targets");

		if (Class.class.equals(c)) {
			throw new IllegalArgumentException("xxx");
		}

		if (c.isInterface()) {
			throw new IllegalArgumentException(
					"May not test methods in Interface: " + c.getName());
		}

		logger.debug("addToTests class: " + c.getName());

		// TODO
		// This doesn't check for inherited method yet

		final boolean isClassAbstract = isAbstract(c.getModifiers());
		final boolean isEnum = c.isEnum();

		for (final Method method : c.getDeclaredMethods()) {

			final String methodName = method.getName();

			if (methodName.indexOf('$') != -1) {
				continue; // do not handle generated methods
			}

			if (isEnum && methodName.equals("valueOf")) {
				continue; // skip enums' "valueOf()" methods
			}

			if (!isClassAbstract || isStatic(method.getModifiers())) {

				params.add(new Object[]{
					new MemberHolder(instance, method, targets)
				});
			}
		}

		if (!isClassAbstract && !isEnum && !c.isSynthetic()) {

			for (final Constructor<?> constructor : c.getDeclaredConstructors()) {

				if (isTransient(constructor.getModifiers())) {
					continue;
				}

				params.add(new Object[]{
					new MemberHolder(null, constructor, targets)
				});
			}
		}
	}

	/**
	 * construct the right instance+method parameters for the @
	 * {@link AvcParameterized} test class that inherits this one, from a list
	 * of classes or instance objects: Classes will be tested with default
	 * instances, whereas instance objects will be tested for themselves.
	 * 
	 * @param targets
	 *            the list of classes or instance objects
	 * @return the right instance+method parameters for the @
	 *         {@link AvcParameterized} test class that inherits this one
	 * @throws ClassNotFoundException
	 */
	protected static Collection<Object[]> parametersScanProject(
			final Object... targets) throws ClassNotFoundException {

		// declared classes

		final Collection<Object[]> params = parametersDoNotScanProject(targets);

		// add all sources

		final File sourceDir = new File("src/main/java");

		final Class<?>[] fileClasses = loadFileClasses(sourceDir);

		for (final Class<?> fileClass : fileClasses) {

			// add only sources that were not declared

			boolean foundInTargets = false;

			for (final Object target : targets) {

				if (target instanceof Class<?>) {

					if (fileClass.equals(target)) {

						foundInTargets = true;

						break;
					}

				} else {

					if (fileClass.equals(target.getClass())) {

						foundInTargets = true;

						break;
					}
				}
			}

			if (!foundInTargets && !fileClass.isInterface()) {

				addToTests(params, null, fileClass, targets);
			}
		}

		// assert all declared Java class or instance targets can be found in
		// local Java source files

		for (final Object target : targets) {

			// Skip plain instances: They can have been declared in order to
			// give an instantiated parameter.
			//
			if (target instanceof Class<?>) {

				final Class<?> c = (Class<?>) target;

				if (!contains(fileClasses, c)) {

					throw new IllegalArgumentException(
							"Declared target class was not found in Java source files: "
									+ c.getName());
				}
			}
		}

		// end

		return params;
	}

	/**
	 * @return a {@link String} representation of this test class, to use by
	 *         {@link org.junit.runner.Description}.
	 */
	@Override
	public final String toString() {

		final StringBuilder sb = new StringBuilder();

		sb.append(member.getDeclaringClass().getName()).append(".");

		sb.append(member.getName());

		sb.append('[');

		boolean start = true;

		for (final Class<?> paramType : paramTypes) {

			if (start) {

				start = false;

			} else {

				sb.append(", ");
			}

			sb.append(paramType.getName());
		}

		sb.append(']');

		return sb.toString();
	}

	@Test
	public final void testAllNonNullableParametersAreChecked() throws Throwable {

		logger
				.debug("testAllNonNullableParametersAreChecked member: "
						+ member);

		if (member instanceof Constructor<?>) {

			checkAllNonNullableConstructorParameters();

		} else if (isStatic(member.getModifiers())) {

			checkAllNonNullableMethodParameters(null);

		} else if (instance == null) {

			final Class<?> c = member.getDeclaringClass();

			checkAllNonNullableMethodParameters(newInstance(
					"Cannot instantiate Class to test: " + c.getName(), c,
					holder.targets));

		} else {

			checkAllNonNullableMethodParameters(instance);
		}
	}

	/**
	 * check that all calls with <tt>null</tt> parameters not declared as
	 * 
	 * @{@link Nullable}, throw an {@link NullArgumentException}.
	 * 
	 * @param instanceToCheck
	 *            the instance to make the invocations on
	 */
	private void checkAllNonNullableMethodParameters(
			@Nullable final Object instanceToCheck) throws Throwable {

		final Method method = (Method) member;

		if (method.isBridge()) {

			return;
		}

		for (int i = 0; i < paramTypes.length; ++i) {

			if (canonicalParams[i] == null || paramTypes[i].isPrimitive()) {

				continue; // the parameter is @Nullable or primitive
			}

			final Object[] params = ArrayUtils.clone(canonicalParams);

			params[i] = null; // check the null value is checked

			try {

				method.invoke(instanceToCheck, params);

			} catch (final Throwable e) {

				if (isDueToNullCheck(e)) {

					continue; // OK
				}

				//throw e;
			}

			fail("Param #" + i + " " + paramTypes[i].getName()
					+ " didn't fail when passed as null");
		}

		// Now let's test one very peculiar case of canonical invocation...
		// Maybe we are lucky and find one case of null return of a
		// non-Nullable method.

		final Class<?> returnType = method.getReturnType();

		if (returnType != null && !void.class.equals(returnType)
				&& !returnType.isPrimitive()
				&& method.getAnnotation(Nullable.class) == null) {

			final Object result;

			try {

				result = method.invoke(instanceToCheck, canonicalParams);

			} catch (final Throwable e) {

				return; // do nothing
			}

			// check if the method is declared elsewhere, not in our packages.

			final Class<?> declaringClass = method.getDeclaringClass();

			final String packageName = declaringClass.getPackage().getName();

			if (!packageName.startsWith("com.avcompris")
					&& !packageName.startsWith("net.avcompris")) {
				throw new IllegalStateException(
						"Testing non-AvCompris class?: "
								+ declaringClass.getName());
			}

			for (Class<?> c = declaringClass; c != null; c = c.getSuperclass()) {

				if (!c.getPackage().getName().startsWith("com.avcompris")
						&& !c.getPackage().getName()
								.startsWith("net.avcompris")) {

					return; // no offense.
				}
			}

			// well, fine... This method was declared in our package!

			if (result == null) {

				fail("Method returns null without being declared @Nullable: "
						+ method.toString());
			}
		}
	}

	/**
	 * check that all calls with <tt>null</tt> parameters not declared as
	 * 
	 * @{@link Nullable}, throw an {@link NullArgumentException}.
	 */
	private void checkAllNonNullableConstructorParameters() throws Throwable {

		final Constructor<?> constructor = (Constructor<?>) member;

		for (int i = 0; i < paramTypes.length; ++i) {

			if (canonicalParams[i] == null || paramTypes[i].isPrimitive()) {

				continue; // the parameter is @Nullable or primitive
			}

			final Object canonicalParam = canonicalParams[i];

			final Object[] params = ArrayUtils.clone(canonicalParams);

			params[i] = null; // check the null value is checked

			try {

				constructor.newInstance(params);

			} catch (final Throwable e) {

				if (isDueToNullCheck(e)) {

					continue; // OK
				}

				//throw e;
			}

			fail("Param #" + i + " " + paramTypes[i].getName()
					+ " didn't fail when passed as null. CanonicalParam = "
					+ canonicalParam.getClass().getName() + ": "
					+ canonicalParam);
		}
	}

	/**
	 * verify that an error is due to a check on an argument's nullity.
	 * 
	 *  @param e the error to check.
	 *  @return <tt>true</tt> if the error was due to a nullity check.
	 */
	private static boolean isDueToNullCheck(@Nullable final Throwable e) throws Throwable {

		if (e instanceof InvocationTargetException) {

			final Throwable targetException = ((InvocationTargetException) e)
					.getTargetException();

			if (targetException != null) {

				if (targetException instanceof NullArgumentException) {

					return true;
				}

				if (targetException instanceof NullPointerException) {

					for (final StackTraceElement ste : targetException
							.getStackTrace()) {

						if (Preconditions.class.getName().equals(
								ste.getClassName())
								&& "checkNotNull".equals(ste.getMethodName())) {

							return true;
						}
					}
				}
			}
		}

		return false;
	}
}
